web/src/routes/image/[id]/+page.svelte (view raw)
1<script lang="ts">
2 import { onMount } from 'svelte';
3 import { getImage, addTags, removeTags, suggestTags, imageDownloadUrl, type ImageDetail } from '$lib/api';
4
5 let { params } = $props();
6 let id = $derived(parseInt(params.id));
7
8 let img = $state<ImageDetail | null>(null);
9 let loading = $state(true);
10 let error = $state('');
11
12 let tagInput = $state('');
13 let suggestions = $state<string[]>([]);
14 let showSuggestions = $state(false);
15 let tagError = $state('');
16
17 onMount(async () => {
18 try {
19 img = await getImage(id);
20 } catch (err) {
21 error = err instanceof Error ? err.message : 'Failed to load image';
22 } finally {
23 loading = false;
24 }
25 });
26
27 function formatSize(bytes: number): string {
28 if (bytes < 1024) return bytes + ' B';
29 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
30 return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
31 }
32
33 function formatDate(s: string): string {
34 return new Date(s).toLocaleString();
35 }
36
37 async function onTagInput() {
38 const q = tagInput.split(',').pop()?.trim() || '';
39 if (q.length < 1) {
40 suggestions = [];
41 showSuggestions = false;
42 return;
43 }
44 try {
45 const result = await suggestTags(q);
46 suggestions = result.map((t) => t.tag);
47 showSuggestions = suggestions.length > 0;
48 } catch {
49 suggestions = [];
50 showSuggestions = false;
51 }
52 }
53
54 function selectSuggestion(s: string) {
55 const parts = tagInput.split(',').slice(0, -1);
56 parts.push(s);
57 tagInput = parts.join(', ') + ', ';
58 showSuggestions = false;
59 }
60
61 async function handleAddTags() {
62 if (!img) return;
63 const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
64 if (tags.length === 0) return;
65 tagError = '';
66 try {
67 const result = await addTags(img.id, tags);
68 img.tags_detail = result.tags_detail;
69 img.tags = result.tags_detail.map((t) => t.tag);
70 tagInput = '';
71 } catch (err) {
72 tagError = err instanceof Error ? err.message : 'Failed to add tags';
73 }
74 }
75
76 async function handleRemoveTags() {
77 if (!img) return;
78 const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
79 if (tags.length === 0) return;
80 tagError = '';
81 try {
82 const result = await removeTags(img.id, tags);
83 img.tags_detail = result.tags_detail;
84 img.tags = result.tags_detail.map((t) => t.tag);
85 tagInput = '';
86 } catch (err) {
87 tagError = err instanceof Error ? err.message : 'Failed to remove tags';
88 }
89 }
90</script>
91
92{#if loading}
93 <p class="state-msg">Loading...</p>
94{:else if error}
95 <p class="state-msg error">{error}</p>
96{:else if img}
97 <div class="page-head">
98 <h1>{img.filename}</h1>
99 <div class="head-line"></div>
100 </div>
101
102 <div class="layout">
103 <div class="preview">
104 {#if img.thumbnail_path}
105 <img src={imageDownloadUrl(img.id)} alt={img.filename} />
106 {:else}
107 <div class="no-thumb">NO THUMBNAIL</div>
108 {/if}
109 </div>
110
111 <div class="meta">
112 <div class="meta-block uploader-block">
113 <span class="meta-label">UPLOADED BY</span>
114 <div class="uploader-row">
115 {#if img.avatar_url}
116 <img src={img.avatar_url} alt="" class="avatar-img" />
117 {/if}
118 <a href="/browse?uploader={encodeURIComponent(img.uploader_username)}" class="meta-value uploader-link">{img.uploader_username}</a>
119 </div>
120 </div>
121 <div class="meta-block">
122 <span class="meta-label">ID</span>
123 <span class="meta-value">#{img.id}</span>
124 </div>
125 <div class="meta-block">
126 <span class="meta-label">DIMENSIONS</span>
127 <span class="meta-value">{img.width} × {img.height}</span>
128 </div>
129 <div class="meta-block">
130 <span class="meta-label">FILE SIZE</span>
131 <span class="meta-value">{formatSize(img.file_size)}</span>
132 </div>
133 <div class="meta-block">
134 <span class="meta-label">TYPE</span>
135 <span class="meta-value">{img.mime_type}</span>
136 </div>
137 <div class="meta-block">
138 <span class="meta-label">UPLOADED</span>
139 <span class="meta-value">{formatDate(img.created_at)}</span>
140 </div>
141 <div class="meta-block tags-block">
142 <span class="meta-label">TAGS</span>
143 <div class="tag-pills">
144 {#if img.tags_detail.length > 0 || (img.innate_tags && img.innate_tags.length > 0)}
145 {#each img.tags_detail as t (t.tag)}
146 <a href="/browse?tag={encodeURIComponent(t.tag)}" class="tag-pill" title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}">
147 <span class="tag-name">{t.tag}</span>
148 <span class="tag-count">{t.usage_count}</span>
149 </a>
150 {/each}
151 {#if img.innate_tags}
152 {#each img.innate_tags as t (t)}
153 <a href="/browse?tag={encodeURIComponent(t)}" class="tag-pill innate">
154 <span class="tag-name">{t}</span>
155 </a>
156 {/each}
157 {/if}
158 {:else}
159 <span class="none">none</span>
160 {/if}
161 </div>
162 </div>
163
164 {#if img.is_owner}
165 <div class="meta-block edit-tags-block">
166 <span class="meta-label">EDIT TAGS</span>
167 <div class="tag-input-row">
168 <div class="tag-wrapper">
169 <input type="text" placeholder="funny, meme, cat" bind:value={tagInput}
170 oninput={onTagInput}
171 onblur={() => setTimeout(() => { showSuggestions = false; }, 200)}
172 />
173 {#if showSuggestions}
174 <div class="suggestions">
175 {#each suggestions as s}
176 <button type="button" class="sug-btn" onmousedown={() => selectSuggestion(s)}>{s}</button>
177 {/each}
178 </div>
179 {/if}
180 </div>
181 <button class="tag-btn add" onclick={handleAddTags}>ADD</button>
182 <button class="tag-btn remove" onclick={handleRemoveTags}>REMOVE</button>
183 </div>
184 {#if tagError}
185 <span class="tag-err">{tagError}</span>
186 {/if}
187 </div>
188 {/if}
189
190 <a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}>
191 DOWNLOAD ORIGINAL
192 </a>
193 </div>
194 </div>
195
196 <p class="back"><a href="/browse">← BACK TO BROWSE</a></p>
197{/if}
198
199<style>
200 .state-msg {
201 padding: 2rem 0;
202 font-size: 1.1rem;
203 color: #888;
204 }
205 .error {
206 color: #c62828;
207 }
208
209 .page-head {
210 margin-bottom: 1.5rem;
211 }
212 .page-head h1 {
213 word-break: break-all;
214 }
215 .head-line {
216 height: 3px;
217 background: #c62828;
218 width: 3rem;
219 margin-top: 0.4rem;
220 }
221
222 .layout {
223 display: flex;
224 gap: 2rem;
225 flex-wrap: wrap;
226 }
227 .preview {
228 flex: 1;
229 min-width: 300px;
230 background: #1a1a1a;
231 display: flex;
232 align-items: center;
233 justify-content: center;
234 }
235 .preview img {
236 display: block;
237 width: 100%;
238 height: auto;
239 max-height: 70vh;
240 object-fit: contain;
241 }
242 .no-thumb {
243 padding: 4rem 2rem;
244 color: #555;
245 font-family: 'Oswald', sans-serif;
246 font-weight: 500;
247 font-size: 0.9rem;
248 letter-spacing: 0.12em;
249 }
250
251 .meta {
252 width: 260px;
253 flex-shrink: 0;
254 display: flex;
255 flex-direction: column;
256 gap: 1rem;
257 }
258 .meta-block {
259 display: flex;
260 flex-direction: column;
261 gap: 0.15rem;
262 }
263 .meta-label {
264 font-family: 'Oswald', sans-serif;
265 font-weight: 500;
266 font-size: 0.7rem;
267 letter-spacing: 0.15em;
268 color: #999;
269 }
270 .meta-value {
271 font-family: 'Inter', sans-serif;
272 font-size: 0.9rem;
273 font-weight: 600;
274 color: #1a1a1a;
275 }
276
277 .uploader-row {
278 display: flex;
279 align-items: center;
280 gap: 0.4rem;
281 }
282 .uploader-link {
283 color: #1a1a1a;
284 text-decoration: none;
285 }
286 .uploader-link:hover {
287 color: #c62828;
288 text-decoration: underline;
289 }
290
291 .avatar-img {
292 width: 1.5rem;
293 height: 1.5rem;
294 object-fit: cover;
295 flex-shrink: 0;
296 }
297
298 .tags-block {
299 flex: 1;
300 }
301 .tag-pills {
302 display: flex;
303 flex-wrap: wrap;
304 gap: 0.35rem;
305 }
306 .tag-pill {
307 display: inline-flex;
308 align-items: center;
309 gap: 0.3rem;
310 background: #1a1a1a;
311 padding: 0.25rem 0.5rem 0.25rem 0.65rem;
312 font-family: 'Inter', sans-serif;
313 font-size: 0.78rem;
314 font-weight: 500;
315 color: #eee;
316 text-decoration: none;
317 transition: background 0.15s;
318 }
319 .tag-pill:hover {
320 background: #c62828;
321 text-decoration: none;
322 }
323 .tag-name {
324 font-weight: 600;
325 }
326 .tag-count {
327 background: rgba(255, 255, 255, 0.12);
328 padding: 0.05rem 0.35rem;
329 font-size: 0.68rem;
330 font-weight: 600;
331 color: rgba(255, 255, 255, 0.65);
332 min-width: 1.1rem;
333 text-align: center;
334 }
335 .tag-pill:hover .tag-count {
336 background: rgba(0, 0, 0, 0.2);
337 }
338 .tag-pill.innate {
339 background: #1565c0;
340 }
341 .tag-pill.innate:hover {
342 background: #1976d2;
343 text-decoration: none;
344 }
345 .tag-pill.innate .tag-count {
346 display: none;
347 }
348 .none {
349 color: #bbb;
350 font-size: 0.85rem;
351 }
352
353 .btn {
354 display: inline-flex;
355 align-items: center;
356 align-self: flex-start;
357 padding: 0.55rem 1.25rem;
358 background: #c62828;
359 color: #fff;
360 border: none;
361 font-family: 'Oswald', sans-serif;
362 font-weight: 500;
363 font-size: 0.8rem;
364 letter-spacing: 0.1em;
365 text-decoration: none;
366 text-transform: uppercase;
367 cursor: pointer;
368 transition: background 0.15s;
369 margin-top: 0.5rem;
370 }
371 .btn:hover {
372 background: #b71c1c;
373 text-decoration: none;
374 }
375
376 .back {
377 margin-top: 2rem;
378 }
379 .back a {
380 font-family: 'Oswald', sans-serif;
381 font-weight: 500;
382 font-size: 0.85rem;
383 letter-spacing: 0.1em;
384 }
385
386 .edit-tags-block {
387 border-top: 2px solid #1a1a1a;
388 padding-top: 0.75rem;
389 }
390 .tag-input-row {
391 display: flex;
392 gap: 0.35rem;
393 align-items: flex-start;
394 }
395 .tag-wrapper {
396 flex: 1;
397 position: relative;
398 min-width: 0;
399 }
400 .tag-wrapper input[type="text"] {
401 width: 100%;
402 padding: 0.4rem 0.5rem;
403 border: 2px solid #1a1a1a;
404 border-radius: 0;
405 font-family: 'Inter', sans-serif;
406 font-size: 0.78rem;
407 font-weight: 500;
408 background: #fff;
409 box-sizing: border-box;
410 }
411 .tag-wrapper input[type="text"]:focus {
412 outline: none;
413 border-color: #c62828;
414 }
415 .suggestions {
416 position: absolute;
417 top: 100%;
418 left: 0;
419 right: 0;
420 background: #fff;
421 border: 2px solid #1a1a1a;
422 border-top: none;
423 margin: 0;
424 padding: 0;
425 z-index: 10;
426 max-height: 130px;
427 overflow-y: auto;
428 }
429 .sug-btn {
430 display: block;
431 width: 100%;
432 text-align: left;
433 padding: 0.35rem 0.5rem;
434 border: none;
435 background: none;
436 font-family: 'Inter', sans-serif;
437 font-size: 0.78rem;
438 font-weight: 500;
439 cursor: pointer;
440 border-radius: 0;
441 }
442 .sug-btn:hover {
443 background: #f0f0f0;
444 }
445 .tag-btn {
446 padding: 0.4rem 0.65rem;
447 border: none;
448 font-family: 'Oswald', sans-serif;
449 font-weight: 500;
450 font-size: 0.72rem;
451 letter-spacing: 0.08em;
452 cursor: pointer;
453 white-space: nowrap;
454 transition: background 0.15s;
455 }
456 .tag-btn.add {
457 background: #1a1a1a;
458 color: #fff;
459 }
460 .tag-btn.add:hover {
461 background: #2e7d32;
462 }
463 .tag-btn.remove {
464 background: #1a1a1a;
465 color: #fff;
466 }
467 .tag-btn.remove:hover {
468 background: #c62828;
469 }
470 .tag-err {
471 display: block;
472 margin-top: 0.3rem;
473 color: #c62828;
474 font-family: 'Inter', sans-serif;
475 font-size: 0.72rem;
476 font-weight: 600;
477 }
478</style>